feat(agentex): DM unlinked Slack users a link to connect their account - #412
Merged
Conversation
Completes the identity-link flow. Everything else shipped in #409/#410 and was reachable only through a dev script, because nothing ever handed a user a link. Now an unlinked mention offers one. On a mention where the turn is NOT running as a person -- unlinked, or linked with a credential we can't use (expired session, undecryptable ciphertext) -- the gateway: 1. mints (or reuses) a nonce carrying the Slack identity Slack's HMAC just verified, plus the message that triggered it 2. conversations.open -> chat.postMessage: DMs that user the link 3. chat.postEphemeral: tells them in-channel that a DM is waiting A dead credential is offered the same fix as no credential, since re-linking is the remedy for both. The link is DMed and NEVER posted in a channel. The nonce is a bearer token: whoever opens it gets linked to that Slack identity by signing in as themselves. In a channel, the first person to read it could bind someone else's Slack identity to their own SGP account. So when conversations.open or the DM fails we log and stop rather than falling back anywhere visible -- there is a test asserting the token never appears in a payload addressed to the origin channel. Entirely best-effort. The turn is already proceeding (as the shared bot, or being refused just after), and no failure in here changes that: a Redis outage, a missing scope, a closed DM all end in "no offer" and an unaffected turn. Rate limiting is two layers, for two different problems: - claim_send caps DMs about one live link at 2, so a re-mention re-sends the same link rather than going quiet. - a cooldown key (SLACK_LINK_OFFER_COOLDOWN_S, default 1h) stops a fresh nonce from re-arming that budget every time the old one expires. Without it a persistent mentioner would collect ~12 DMs an hour instead of ~2. It fails OPEN on a Redis error, since never offering is worse and claim_send still bounds it. Offers require SLACK_GATEWAY_PUBLIC_BASE_URL. Unset means no offers at all: the host has to be browser-reachable AND a sibling subdomain of the SGP host or the session cookie never arrives, and a link that cannot work is worse than none. Also adds the email-match defence, OFF by default. The nonce stops an attacker forging someone else's Slack identity. It does not stop them forwarding their OWN link: if a victim clicks it while signed in, the attacker's Slack identity binds to the victim's SGP account, and from then on the attacker's Slack messages run as the victim with the victim's integrations. The confirmation page naming both identities catches a mis-click but reduces to user vigilance against a deliberate attempt. Comparing the Slack account's email to the signed-in SGP account's closes it. It ships disabled because it needs the users:read.email Slack scope, which is not granted (verified: users.lookupByEmail returns missing_scope). Enable IDENTITY_LINK_REQUIRE_EMAIL_MATCH and the scope together -- the check treats an unreadable email as a MISMATCH, not as "skip", so turning the flag on without the scope refuses every link. That direction is deliberate: failing open would silently disable the only defence the moment the scope lapsed. Not included: replaying the stashed turn after linking. pending_turn is recorded for it, but the confirmation page still says "ask me again", and wiring the route back into the gateway is left for a follow-up. Testing: 15 new unit tests. The link-offer ones concentrate on where the token must not go (origin channel, on DM failure) and on the offer never affecting the turn; the email-match ones on failing closed -- unreadable Slack email, missing SGP email -- and on the nonce surviving a refusal so a legitimate owner can still use their own link. Full unit suite: 685 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
||
| from src.domain.services.link_nonce_service import LinkNonceService, LinkRequest | ||
|
|
||
| if not await self._claim_offer_cooldown(inbound): |
There was a problem hiding this comment.
Failed DMs consume the cooldown
When conversations.open or chat.postMessage fails, the cooldown has already been claimed and is not released, causing every subsequent mention to suppress another offer for up to one hour even though the user received no link.
Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/domain/use_cases/slack_gateway_use_case.py
Line: 1270
Comment:
**Failed DMs consume the cooldown**
When `conversations.open` or `chat.postMessage` fails, the cooldown has already been claimed and is not released, causing every subsequent mention to suppress another offer for up to one hour even though the user received no link.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
michael-chou359
added a commit
that referenced
this pull request
Aug 30, 2026
…ink, stop leaking ids (#414) Four fixes to the identity-link work. Three were found by using it — the config surface was too large, the connect link was somewhere people don't look, the confirmation page printed internal ids — and the fourth is a P1 the first one introduced, caught in review. --- # 1. Config surface: 8 vars → 0 #409 → #412 accumulated **eight** environment variables. One was a hazard, one a trap, six were knobs nobody has ever turned. ``` IDENTITY_LINK_SESSION_COOKIE_NAME -> derived from the delegation allowlist IDENTITY_LINK_REQUIRE_EMAIL_MATCH -> enables itself when the scope exists IDENTITY_LINK_NONCE_TTL -> constant IDENTITY_LINK_MAX_DMS -> constant IDENTITY_LINK_CACHE_TTL -> constant IDENTITY_LINK_NEGATIVE_CACHE_TTL -> constant IDENTITY_LINK_FALLBACK_TTL_DAYS -> constant SLACK_LINK_OFFER_COOLDOWN_S -> constant ``` **No behavior change at current settings** — every constant equals the default it replaced. ## The hazard: a cookie name with two sources of truth ``` identity_link_service: IDENTITY_LINK_SESSION_COOKIE_NAME default _identityJwt delegation_headers: AGENTEX_DELEGATION_SESSION_COOKIE_NAMES default _identityJwt ``` `acting_headers()` emits a `Cookie` header that `build_delegation_headers` filters down to its allowlist. Had the two disagreed, the credential would be **stripped in transit** — every linked turn silently losing its acting identity, while the link sat in the database looking stored, valid and healthy. Now derived, so divergence is unrepresentable rather than warned about in a comment. An empty allowlist returns `None` and `acting_headers()` refuses, since emitting a credential that will certainly be stripped is worse than admitting we can't act. ## The trap: a flag that had to move in lockstep with a Slack scope The email check needs `users:read.email`, which isn't granted. The flag kept it off until then — but flag and scope had to be flipped **together**: the flag alone refused *every* link, the scope alone protected nothing. It now enables itself: enforces whenever Slack answers with an email, stands down when it won't. Granting the scope switches the protection on by itself.⚠️ This inverts the unverifiable case from refuse to allow — weaker, deliberately. With no flag to distinguish "scope missing" from "Slack had a bad minute", failing closed would make linking fail at random. The gap isn't attacker-reachable: nobody outside our infrastructure influences whether *our* Slack lookup succeeds. Against what's deployed today (flag off, verifying nothing) it's strictly stronger. ## What remains | Variable | Why | |---|---| | `AGENTEX_CREDENTIAL_ENCRYPTION_KEY` | Secret, no default possible | | `SLACK_GATEWAY_PUBLIC_BASE_URL` | Deployment-specific; the feature's on-switch | | `SLACK_GATEWAY_REQUIRE_LINKED_USER` | Pre-existing, a real product choice | --- # 2. The connect link was invisible The first real offer in production was **delivered correctly and reported as never received**. Both were true: `chat.postMessage` returned `ok` and `conversations.history` confirmed the message in the DM channel — and Slack files bot conversations under **Apps**, not in the Direct messages list. "I've DM'd you a link" pointed at the one place it wasn't. ``` before after ephemeral: "I've DM'd you a link — ephemeral: "<Connect your SGP account> check your DMs" …also sent to <our DM>" clicks to connect: 2, if you find it clicks to connect: 1 ``` ## Why this doesn't weaken anything An ephemeral has **exactly the same audience as a DM**: rendered for one user, absent from channel history and search. The exposure argument that made this DM-only never applied to an ephemeral — so routing someone through a conversation they can't find, to click a link we could hand them directly, bought nothing. The DM stays because ephemerals are **transient**: reload before clicking and it's gone, and the offer cooldown would then block a retry for an hour. So the ephemeral carries the link *plus* a deep link to the DM as the durable copy. ## The invariant, stated properly > The nonce is a bearer token. It may go anywhere **exactly one person can see it** (the > user's DM, an ephemeral addressed to them) and **nowhere that lands in channel > history**. The test that asserted *"never in a payload addressed to the origin channel"* now asserts *"never in a `chat.postMessage` outside the user's own DM"*. That matters — the old wording would have blocked this change while the actual risk was never touched by it. A test encoding the implementation rather than the property blocks correct changes. ## Also - `conversations.open` moved above the send-cap check (idempotent; both branches need the channel id). - **Past the DM cap the user still gets the live link** — the cap limits DMs, not what we can show the person in front of us. - `slack.com/app_redirect`, not `slack://`, which fails on the web client. --- # 3. The connect page printed internal ids It fell back to raw identifiers when it couldn't name an identity: ```python slack_who = link_request.display_name or link_request.external_user_id # U0B01457V24 <dd>{email or sgp_user_id}</dd> # 5da8f784-… ``` That leaks an internal id and buys nothing for it. The two identity rows exist so the person clicking can answer *"is this **my** Slack account?"* and stop if it isn't — the only defence against a link forwarded to them. Nobody recognises their own Slack member id or SGP uuid, so the fallback never made that question answerable. It made an unanswerable question **look** answered, which is worse than showing nothing. - Neither side ever falls back to an id; an unnameable identity renders a placeholder. - A missing Slack name is now **re-read live** — a transient Slack failure when the nonce was minted shouldn't permanently degrade the page, and `users.info` needs only `users:read`, which is granted. - When either side is unnamed, the caution changes from *"if either name above isn't you, don't continue"* to saying the match can't be confirmed here. Claiming someone verified something they couldn't is the actual harm. - The success page likewise stops printing the uuid when there's no email. --- # 4. A multi-name cookie allowlist got narrowed (P1, introduced by §1) Deriving the cookie name fixed one hazard and introduced another. `session_cookie_name()` returned only the **first** entry of `AGENTEX_DELEGATION_SESSION_COOKIE_NAMES`, and the read path matched against that alone — so with a multi-name allowlist, a session carried by any later name was rejected. Linking failed with *"couldn't read your session"* for a cookie the delegation layer would have forwarded quite happily. The allowlist is the set of names a deployment treats as valid sessions, so one arriving under the second entry is exactly as legitimate as the first. Reading and writing genuinely differ, so they're now separate functions rather than one doing double duty — which is how the bug got in: ``` session_cookie_names() every accepted name, in preference order (READ) session_cookie_name() the single canonical name to emit under (WRITE) ``` Allowlist order beats header order when several are present, so the stored credential is the canonical cookie whenever it's there — which is also the name we emit under, keeping the common case exact. Emitting a value that arrived under a later name as the canonical one is safe: the credential is a session JWT, validated downstream on its contents, not on its label. Flagged in the docstring — if a downstream ever became name-sensitive this would need the originating name stored alongside the credential, which is a schema change and not worth making speculatively. --- ## Testing **26 new, 6 rewritten.** The rewrites are the interesting ones — they invert assertions that encoded superseded designs: | Was | Now | |---|---| | nonce must **not** appear in the ephemeral | nonce **must** (single-viewer) | | nothing addressed to the origin channel carries it | nothing **broadcast** carries it | | email flag off → no Slack call | check always runs; scope decides | | unreadable email fails closed | fails open, loudly logged | New coverage: the cookie name follows the delegation allowlist and the emitted cookie survives the delegation filter under a non-default name; `acting_headers` refuses when delegation is off; the ephemeral carries the link and points at the durable copy; an unreachable DM offers nothing. **708 unit tests pass.** The 14 Redis integration tests still pass against a real Redis after the TTL constants moved. `ruff` clean. ## Not fixed here The nonce is still 10 minutes — sized assuming prompt discovery, which this incident showed was optimistic. With the link now inline the find-it delay largely disappears, so I'd rather see whether it's still a problem than change two things at once. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- greptile_comment --> <h3>Greptile Summary</h3> The PR simplifies identity-link configuration, improves Slack link discoverability, removes raw identifiers from confirmation pages, and fixes session extraction for multi-cookie delegation allowlists. - Derives identity-link cookie handling from the delegation allowlist and accepts sessions under any configured cookie name. - Places the connection URL in the user-specific ephemeral response while retaining the DM as a durable copy. - Replaces internal identifier fallbacks with explicit unknown-identity messaging and performs best-effort profile refreshes. - Converts identity-link TTLs, limits, and cooldowns from environment settings to fixed defaults. <details><summary><h3>Confidence Score: 5/5</h3></summary> The PR appears safe to merge because the previously reported multi-cookie allowlist failure is fixed and no blocking eligible failure remains. No blocking failure remains. </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | agentex/src/api/routes/integrations.py | Accepts every allowlisted session-cookie name, adds best-effort identity naming and self-enabling email comparison, and removes raw identifier fallbacks. | | agentex/src/domain/services/identity_link_service.py | Derives accepted and emitted cookie names from the delegation allowlist and refuses acting headers when cookie delegation is disabled. | | agentex/src/domain/services/link_nonce_service.py | Replaces nonce lifetime and DM-send environment settings with constants matching their former defaults. | | agentex/src/domain/use_cases/slack_gateway_use_case.py | Surfaces the bearer link in a user-specific ephemeral response, retains a durable DM copy, and adds DM deep links. | | agentex/tests/unit/api/test_integrations_routes.py | Adds regression coverage for later allowlisted cookies, identity placeholders, live Slack-name recovery, and email comparison behavior. | | agentex/tests/unit/services/test_identity_link_service.py | Verifies canonical cookie derivation, delegation forwarding, and disabled-delegation behavior. | | agentex/tests/unit/use_cases/test_slack_gateway_use_case.py | Verifies that connection links remain single-viewer, discoverable, durable through DM delivery, and available after the DM cap. | </details> <details><summary><h3>Flowchart</h3></summary> ```mermaid %%{init: {'theme': 'neutral'}}%% flowchart LR Request[Authenticated link confirmation] --> Parse[Parse inbound cookies] Allowlist[Delegation cookie allowlist] --> Parse Parse --> Select[Select first present allowlisted cookie] Select --> Store[Encrypt and store session JWT] Store --> Resolve[Resolve linked Slack identity] Allowlist --> Canonical[Choose canonical first cookie name] Resolve --> Emit[Emit JWT under canonical cookie name] Emit --> Delegate[Delegation filter forwards acting-user cookie] ``` </details> <sub>Reviews (4): Last reviewed commit: ["fix(agentex): accept a session under any..."](bbceae9) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=58328790)</sub> <!-- /greptile_comment --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The last piece of the identity-link flow. #409 added the storage, #410 the gateway
wiring and the link routes — but nothing ever handed a user a link, so the only way
in was a dev script. Now an unlinked mention offers one.
When a turn is not running as a person — unlinked, or linked with a credential we
can't use (expired session, undecryptable ciphertext) — the gateway:
A dead credential gets the same offer as no credential, since re-linking is the fix
for both.
The link is DMed and never posted in a channel
This is the part worth reviewing closely. The nonce is a bearer token: whoever
opens it gets linked to that Slack identity by signing in as themselves. Posted in a
channel, the first person to read it could bind someone else's Slack identity to
their own SGP account.
So when
conversations.openor the DM fails, we log and stop — there is no fallbackto anywhere visible. A test asserts the token never appears in any payload addressed
to the origin channel, and another that a failed DM leaks it nowhere.
Best-effort by construction
The turn is already proceeding (as the shared bot, or being refused immediately
after), and nothing in the offer path may change that. A Redis outage, a missing
scope, a closed DM — all end in "no offer" and an unaffected turn.
Offers also require
SLACK_GATEWAY_PUBLIC_BASE_URL. Unset means no offers at all:the host must be browser-reachable and a sibling subdomain of the SGP host or the
session cookie never arrives at the callback. A link that cannot work is worse than
no link.
Rate limiting, two layers for two problems
claim_send(from feat(agentex): run Slack turns as the invoking user, and the flow to link them #410) caps DMs about one live link at 2, so a re-mentionre-sends the same link rather than going quiet.
SLACK_LINK_OFFER_COOLDOWN_S, default 1h) stops a fresh noncefrom re-arming that budget every time the old one expires. Without it, a persistent
mentioner collects ~12 DMs an hour instead of ~2. It fails open on a Redis
error — never offering is the worse failure, and
claim_sendstill bounds it.Email match — shipped OFF
The nonce stops an attacker forging someone else's Slack identity. It does not
stop them forwarding their own link: if a victim clicks it while signed in, the
attacker's Slack identity binds to the victim's SGP account, and from then on the
attacker's Slack messages run as the victim, with the victim's integrations. The
confirmation page naming both identities catches a mis-click but reduces to user
vigilance against a deliberate attempt.
Comparing the Slack account's email against the signed-in SGP account's closes it.
It ships disabled because it needs the
users:read.emailSlack scope, which is notgranted — verified,
users.lookupByEmailreturnsmissing_scope. EnableIDENTITY_LINK_REQUIRE_EMAIL_MATCHand the scope together.the flag on without the scope refuses every link. That direction is deliberate:
failing open would silently disable the only defence the moment the scope lapsed.
Tests pin both directions.
Not included
Replaying the stashed turn.
pending_turnis recorded for it, but theconfirmation page still says "ask me again in Slack". Wiring the link route back into
the gateway is a coupling worth doing on its own, with care that a replay failure
can't undo a successful link.
Testing
15 new unit tests, aimed at the failure modes rather than the happy path:
nowhere; no offer without a public base URL; the send cap acknowledging in-channel
instead of going silent; the cooldown suppressing before a nonce is even minted; a
Redis error swallowed; the DM text warning against forwarding;
pending_turnstashed for a later replay.
case-insensitive) emails link; a mismatch refuses and leaves the nonce intact,
so a legitimate owner can still use their own link; unreadable Slack email and
missing SGP email both fail closed.
Full unit suite: 685 passed.
ruffclean.The 193 local integration errors are the pre-existing testcontainers/Mongo issue in
files this PR doesn't touch — they reproduce identically on a clean checkout of main.
Deploy notes
Nothing here activates on its own:
SLACK_GATEWAY_PUBLIC_BASE_URLSLACK_LINK_OFFER_COOLDOWN_SIDENTITY_LINK_REQUIRE_EMAIL_MATCHScopes:
im:writeandchat:writeare already granted, so DMs and ephemerals worktoday.
users:read.emailis the only addition needed, and only for the email match.🤖 Generated with Claude Code
Greptile Summary
Adds Slack account-link offers for users whose turns cannot run under a usable personal identity, with bearer links delivered only through DMs and an ephemeral channel acknowledgment.
Confidence Score: 4/5
The failed-delivery cooldown behavior should be fixed before merging because a transient Slack failure can prevent the user from receiving a link for an hour.
The gateway records the offer cooldown before attempting DM delivery and does not undo it on either Slack failure path, causing later mentions to be suppressed despite no successful offer.
Files Needing Attention: agentex/src/domain/use_cases/slack_gateway_use_case.py
Important Files Changed
Sequence Diagram
sequenceDiagram participant U as Slack user participant G as Slack gateway participant R as Redis/nonce service participant S as Slack API participant I as Link confirmation route G->>G: Resolve invoking identity alt No usable personal identity G->>R: Claim offer cooldown G->>R: Create or reuse nonce and claim send G->>S: conversations.open G->>S: chat.postMessage with bearer link G->>S: chat.postEphemeral acknowledgment U->>I: Open link and confirm while signed in I->>I: Optionally compare Slack and SGP email I->>R: Consume nonce and persist identity link endPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat(agentex): DM unlinked Slack users a..." | Re-trigger Greptile